agentmux_srv\drone\executor\blocks/
condition.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Condition block — boolean expression evaluator.
5//!
6//! Phase 1 supports a deliberately tiny grammar to avoid a JS sandbox:
7//!
8//!   `<lhs> <op> <rhs>` where op ∈ {`==`, `!=`, `<`, `<=`, `>`, `>=`}
9//!
10//! Both sides are `{{}}`-resolved first. If both sides parse as numbers
11//! the comparison is numeric; otherwise it's string. The block's
12//! output is the boolean result, exposed as `{{<this_id>.result}}`.
13//!
14//! Phase 2 adds `&&` / `||` / `!` and grouping; Phase 3 may swap to a
15//! real expression parser (`evalexpr`, etc).
16
17use serde_json::{json, Value};
18
19use crate::drone::data_flow::ExecutionScope;
20use crate::drone::types::FlowNode;
21
22pub async fn run(node: &FlowNode, scope: &ExecutionScope) -> Result<Value, String> {
23    let expr = node
24        .data
25        .get("expr")
26        .and_then(|v| v.as_str())
27        .ok_or_else(|| "Condition block missing `expr`".to_string())?;
28    let resolved = scope.resolve(expr);
29    let result = eval_simple(&resolved)?;
30    Ok(json!({ "result": result }))
31}
32
33fn eval_simple(expr: &str) -> Result<bool, String> {
34    let trimmed = expr.trim();
35    // Bare boolean literal.
36    if trimmed.eq_ignore_ascii_case("true") {
37        return Ok(true);
38    }
39    if trimmed.eq_ignore_ascii_case("false") {
40        return Ok(false);
41    }
42    let (lhs, op, rhs) = split_binop(trimmed)
43        .ok_or_else(|| format!("could not parse condition: `{trimmed}`"))?;
44    let lhs = lhs.trim().trim_matches(|c| c == '\'' || c == '"');
45    let rhs = rhs.trim().trim_matches(|c| c == '\'' || c == '"');
46
47    if let (Ok(a), Ok(b)) = (lhs.parse::<f64>(), rhs.parse::<f64>()) {
48        return Ok(match op {
49            "==" => (a - b).abs() < f64::EPSILON,
50            "!=" => (a - b).abs() >= f64::EPSILON,
51            "<" => a < b,
52            "<=" => a <= b,
53            ">" => a > b,
54            ">=" => a >= b,
55            _ => unreachable!(),
56        });
57    }
58
59    Ok(match op {
60        "==" => lhs == rhs,
61        "!=" => lhs != rhs,
62        "<" => lhs < rhs,
63        "<=" => lhs <= rhs,
64        ">" => lhs > rhs,
65        ">=" => lhs >= rhs,
66        _ => unreachable!(),
67    })
68}
69
70/// Splits at the first occurrence of a recognized comparison operator.
71/// Multi-char operators are tried before single-char.
72fn split_binop(s: &str) -> Option<(&str, &str, &str)> {
73    for op in &["==", "!=", "<=", ">=", "<", ">"] {
74        if let Some(idx) = s.find(op) {
75            // Reject `<=` matching when the actual op is `<`.
76            // Already handled by the order above.
77            return Some((&s[..idx], op, &s[idx + op.len()..]));
78        }
79    }
80    None
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::drone::types::NodePosition;
87
88    fn mk(expr: &str) -> FlowNode {
89        FlowNode {
90            id: "c".to_string(),
91            position: NodePosition::default(),
92            data: json!({ "kind": "condition", "expr": expr }),
93            node_type: String::new(),
94        }
95    }
96
97    #[tokio::test]
98    async fn numeric_lt() {
99        let n = mk("3 < 5");
100        let out = run(&n, &ExecutionScope::new()).await.unwrap();
101        assert_eq!(out, json!({ "result": true }));
102    }
103
104    #[tokio::test]
105    async fn string_eq() {
106        let n = mk("'a' == 'a'");
107        let out = run(&n, &ExecutionScope::new()).await.unwrap();
108        assert_eq!(out, json!({ "result": true }));
109    }
110
111    #[tokio::test]
112    async fn rejects_unparseable() {
113        let n = mk("foo bar baz");
114        let r = run(&n, &ExecutionScope::new()).await;
115        assert!(r.is_err());
116    }
117
118    #[tokio::test]
119    async fn bare_true() {
120        let n = mk("true");
121        let out = run(&n, &ExecutionScope::new()).await.unwrap();
122        assert_eq!(out, json!({ "result": true }));
123    }
124
125    #[tokio::test]
126    async fn resolves_var_in_expr() {
127        let n = mk("{{var.count}} >= 10");
128        let mut scope = ExecutionScope::new();
129        scope.vars.insert("count".to_string(), json!(15));
130        let out = run(&n, &scope).await.unwrap();
131        assert_eq!(out, json!({ "result": true }));
132    }
133}